
Android Math Snake
Keypoints
* Bachelorarbeit
* Game Experience Questionaire
* 3D Development
- Arbeiten Third Party Assets
- Durchführung und Auswertung von Nutzertests
Project Description
The game "Android Math Snake" was developed to teach children addition using the concept of number pairs. Two versions of the game were created to test the impact of perspective on both the enjoyment and the learning experience: one from a first-person perspective and another from a third-person perspective. The results of user testing indicated that both versions offered a high-quality gameplay experience, but players found the third-person perspective more enjoyable.
Refactoring
After completing the original project as part of my bachelor thesis, I extensively refactored it to incorporate my expanded programming knowledge and improve the codebase. The first step in the refactoring process was to improve the project setup and folder structure. The original structure was relatively standard but had some limitations, it looked like this:
* Assets
* Resources
* Sprites
* Scripts
* Prefabs
* Materials
* EZ Camera Shake
* ...
For example, when working with the Snake.cs script, it required frequent navigation between different folders, such as when editing the script and later modifying the related Snake.mat material. To improve this, the new structure organizes the assets more logically:
* Assets
* Third-Party
* EZ Camera Shake
* MathSnake
* Player
* Snake.cs
* Snake.prefab
* ...
* ...
* ...
The clear separation of project data and external libraries now makes the structure more readable for developers. Components that are directly related to each other are grouped together. With this approach, everything related to the player is now grouped within the MathSnake folder. This serves not only as a container but also to store higher-level scripts, such as the GameController and the GameContext, which are explained further below. This new structure significantly improves navigation and organization, making it easier to find and edit the necessary files.
Everywhere MonoBehaviours
Instead of unnecessarily inheriting many classes from MonoBehaviour, I restructured the code to keep Unity-independent logic in separate classes. This improves modularity and testability.
#nullable enable
using MathSnake.Extensions;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace MathSnake.Player
{
/// <summary>
/// Represents a controller for the snakes body for adding and removing new parts.
/// </summary>
public class SnakeBodyController
{
private readonly Transform bodyParent;
private readonly SnakeBodyBase tail;
private readonly List<SnakeBodyBase> bodyParts = new();
private readonly GameContext context;
private SnakeSettings SnakeSettings => context.SnakeSettings;
private Rigidbody nextFollowTarget;
/// <summary>
/// Gets the last added numbered body part or null if no numbered body part was added.
/// </summary>
public NumberedSnakeBody? LastAddedNumber => bodyParts.OfType<NumberedSnakeBody>().LastOrDefault();
/// <summary>
/// Initializes a new instance of the <see cref="SnakeBodyController"/> class.
/// </summary>
/// <param name="snakeHead">The head of the snake.</param>
/// <param name="bodyParent">The parent object for all created body parts.</param>
/// <param name="context">The game context to use.</param>
public SnakeBodyController(Transform snakeHead, Transform bodyParent, GameContext context)
{
this.context = context;
this.bodyParent = bodyParent;
tail = GameObject.Instantiate(SnakeSettings.SnakeTailPrefab, snakeHead.position, snakeHead.rotation, bodyParent);
var snakePhysics = snakeHead.gameObject.GetComponentOrThrow<Rigidbody>();
tail.Initialize(context.Player.SnakeMovement, snakePhysics, SnakeSettings);
bodyParts.Add(tail);
nextFollowTarget = snakePhysics;
}
/// <summary>
/// Creates a new body part for the snake.
/// Note: The body part will be created without a number.
/// </summary>
public void CreateBodyPart()
{
var bodyPart = GameObject.Instantiate(SnakeSettings.SnakeBodyPrefab, tail.transform.position, tail.transform.rotation, bodyParent);
bodyPart.Initialize(context.Player.SnakeMovement, nextFollowTarget, SnakeSettings);
bodyPart.name = $"BodyPart {bodyParts.Count}";
bodyParts.Add(bodyPart);
nextFollowTarget = bodyPart.BodyPartRigidbody;
tail.ConnectTo(bodyPart.BodyPartRigidbody);
//Print the eaten number on the bodyPart if not 0
//if (num != 0)
//{
// TextMeshPro mesh = bpm.GetComponentInChildren<TextMeshPro>();
// mesh.text = num.ToString();
// currentNums += num;
//}
}
/// <summary>
/// Creates a new body part for the snake with a specified number.
/// </summary>
/// <param name="num">The number to assign to the created body part.</param>
public void CreateBodyPart(int num)
{
var bodyPart = GameObject.Instantiate(SnakeSettings.NumberedSnakeBodyPrefab, tail.transform.position, tail.transform.rotation, bodyParent);
bodyPart.Initialize(num, context.Player.SnakeMovement, nextFollowTarget, SnakeSettings);
bodyPart.name = $"BodyPart {bodyParts.Count} ({num})";
bodyParts.Add(bodyPart);
nextFollowTarget = bodyPart.BodyPartRigidbody;
tail.ConnectTo(bodyPart.BodyPartRigidbody);
}
public void DestroyLastBodyPart()
{
if (LastAddedNumber == null)
{
return;
}
var lastBodyPart = LastAddedNumber;
tail.ConnectTo(lastBodyPart.Target);
nextFollowTarget = lastBodyPart.Target;
bodyParts.Remove(lastBodyPart);
GameObject.Destroy(lastBodyPart.gameObject);
}
}
}
#nullable enable
using MathSnake.Extensions;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace MathSnake.Player
{
/// <summary>
/// Represents a controller for the snakes body for adding and removing new parts.
/// </summary>
public class SnakeBodyController
{
private readonly Transform bodyParent;
private readonly SnakeBodyBase tail;
private readonly List<SnakeBodyBase> bodyParts = new();
private readonly GameContext context;
private SnakeSettings SnakeSettings => context.SnakeSettings;
private Rigidbody nextFollowTarget;
/// <summary>
/// Gets the last added numbered body part or null if no numbered body part was added.
/// </summary>
public NumberedSnakeBody? LastAddedNumber => bodyParts.OfType<NumberedSnakeBody>().LastOrDefault();
/// <summary>
/// Initializes a new instance of the <see cref="SnakeBodyController"/> class.
/// </summary>
/// <param name="snakeHead">The head of the snake.</param>
/// <param name="bodyParent">The parent object for all created body parts.</param>
/// <param name="context">The game context to use.</param>
public SnakeBodyController(Transform snakeHead, Transform bodyParent, GameContext context)
{
this.context = context;
this.bodyParent = bodyParent;
tail = GameObject.Instantiate(SnakeSettings.SnakeTailPrefab, snakeHead.position, snakeHead.rotation, bodyParent);
var snakePhysics = snakeHead.gameObject.GetComponentOrThrow<Rigidbody>();
tail.Initialize(context.Player.SnakeMovement, snakePhysics, SnakeSettings);
bodyParts.Add(tail);
nextFollowTarget = snakePhysics;
}
/// <summary>
/// Creates a new body part for the snake.
/// Note: The body part will be created without a number.
/// </summary>
public void CreateBodyPart()
{
var bodyPart = GameObject.Instantiate(SnakeSettings.SnakeBodyPrefab, tail.transform.position, tail.transform.rotation, bodyParent);
bodyPart.Initialize(context.Player.SnakeMovement, nextFollowTarget, SnakeSettings);
bodyPart.name = $"BodyPart {bodyParts.Count}";
bodyParts.Add(bodyPart);
nextFollowTarget = bodyPart.BodyPartRigidbody;
tail.ConnectTo(bodyPart.BodyPartRigidbody);
//Print the eaten number on the bodyPart if not 0
//if (num != 0)
//{
// TextMeshPro mesh = bpm.GetComponentInChildren<TextMeshPro>();
// mesh.text = num.ToString();
// currentNums += num;
//}
}
/// <summary>
/// Creates a new body part for the snake with a specified number.
/// </summary>
/// <param name="num">The number to assign to the created body part.</param>
public void CreateBodyPart(int num)
{
var bodyPart = GameObject.Instantiate(SnakeSettings.NumberedSnakeBodyPrefab, tail.transform.position, tail.transform.rotation, bodyParent);
bodyPart.Initialize(num, context.Player.SnakeMovement, nextFollowTarget, SnakeSettings);
bodyPart.name = $"BodyPart {bodyParts.Count} ({num})";
bodyParts.Add(bodyPart);
nextFollowTarget = bodyPart.BodyPartRigidbody;
tail.ConnectTo(bodyPart.BodyPartRigidbody);
}
public void DestroyLastBodyPart()
{
if (LastAddedNumber == null)
{
return;
}
var lastBodyPart = LastAddedNumber;
tail.ConnectTo(lastBodyPart.Target);
nextFollowTarget = lastBodyPart.Target;
bodyParts.Remove(lastBodyPart);
GameObject.Destroy(lastBodyPart.gameObject);
}
}
}
Context instead of Singletons
To avoid the commonly used Singleton pattern, I introduced a GameContext that contains all the important game objects and controllers, serving as a central data source.
namespace MathSnake
{
/// <summary>
/// Represents the context of the game, holding global settings and states that affect game behavior.
/// This includes settings for features such as rotting mechanics for items in the game.
/// </summary>
public class GameContext
{
private GameSettings gameSettings;
private Snake? player;
/// <summary>
/// Gets the settings related to the rotting behavior of items within the game.
/// </summary>
public EatableSettings EatableSettings => gameSettings.RottingSettings;
/// <summary>
/// Gets the settings related to the snake behavior within the game.
/// </summary>
public SnakeSettings SnakeSettings => gameSettings.SnakeSettings;
/// <summary>
/// Gets the tilemap controller.
/// </summary>
public TilemapController TilemapController { get; }
/// <summary>
/// Gets the UI controller.
/// </summary>
public UiController UiController { get; }
/// <summary>
/// Gets the background music controller.
/// </summary>
public BackgroundMusicController BackgroundMusicController { get; }
/// <summary>
/// Gets the game master.
/// </summary>
public IGameController GameMaster { get; }
/// <summary>
/// Gets or sets the player snake.
/// </summary>
public Snake Player
{
get
{
return NotAssignedException.ThrowIfNull(player);
}
set
{
player = value;
}
}
/// <summary>
/// Initializes a new instance of the <see cref="GameContext"/> class with specified rotting settings.
/// </summary>
/// <param name="tilemapController">The tilemap controller to provide.</param>
/// <param name="musicManager">The music manager to provide.</param>
/// <param name="uiController">The UI controller to provide.</param>
/// <param name="gameSettings">The settings to be used within the game context.</param>
public GameContext(
IGameController gameMaster,
TilemapController tilemapController,
BackgroundMusicController musicManager,
UiController uiController,
GameSettings gameSettings)
{
GameMaster = gameMaster;
this.gameSettings = gameSettings;
UiController = uiController;
TilemapController = tilemapController;
BackgroundMusicController = musicManager;
}
}
}
Benefit: The use of a context reduces dependencies and increases flexibility. This can be further enhanced if the context also implements an interface, and all elements within it are provided as interfaces. The context also offers the advantage that only one parameter needs to be passed in functions or constructors, instead of a large list. This context can easily be passed to non-MonoBehaviour objects via the constructor. For MonoBehaviours, however, the situation is different. The constructor cannot be overridden, as the objects are created using UnityEngine.Object.Instantiate(...) and always use the parameterless constructor. To ensure that the context is available to these objects as well, I wrote an Initialize function, which must be called first before the object can be used.
/// <summary>
/// Initializes a new instance of the <see cref="Snake"/> class.
/// </summary>
/// <param name="context">The game context to use.</param>
public void Initialize(GameContext context)
{
if (isInitialized)
{
return;
}
gameContext = context;
gameContext.Player = this;
snakeMouth = gameObject.GetComponentInChildrenOrThrow<SnakeMouth>();
snakeMouth.Initialize(context.SnakeSettings);
snakeMovement = gameObject.GetComponentOrThrow<SnakeHeadMovement>();
SnakeMovement.Initialize(SnakeHead, context.SnakeSettings);
SnakeMouth.Eaten += OnEatenTriggered;
SnakeMovement.StartMovement();
snakeBodyController = new(SnakeHead, SnakeBodyParent, context);
isInitialized = true;
}
Exceptionhandling
In software development, exception handling is an essential component for catching and managing errors at runtime. If an exception cannot be handled, it should pass helpful information upwards to quickly identify and resolve the issue. In Unity, custom-defined exceptions are especially useful for achieving this. For this project, I defined two exceptions that I find particularly important.
SerializeFieldNotAssignedException
This exception is used to ensure that all fields marked with [SerializeField] are properly assigned in the Unity Inspector. With this exception, the question "Why is this object null? The code is correct." is no longer an issue, as you are immediately pointed to the error. Typically, this exception is used only within Unity components or MonoBehaviour classes.
#nullable enable
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.CompilerServices;
namespace MathSnake.Exceptions
{
/// <summary>
/// Represents errors that occur when a serialized field required by Unity is not assigned.
/// This exception is specifically designed to be thrown when a serialized field expected
/// to be set in the Unity Inspector is found to be null at runtime, aiding in debugging.
/// </summary>
[Serializable]
public class SerializeFieldNotAssignedException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="SerializeFieldNotAssignedException"/> class.
/// </summary>
/// <param name="filePath">The path of the file where the unassigned field was found. This parameter is automatically populated by the compiler.</param>
/// <param name="callerMemberName">The name of the member (method, property, etc.) where the unassigned field was detected. This parameter is automatically populated by the compiler.</param>
public SerializeFieldNotAssignedException([CallerFilePath] string filePath = "", [CallerMemberName] string callerMemberName = "") :
base($"The object '{callerMemberName}' of '{Path.GetFileName(filePath)}' was not assigned via the inspector.")
{
}
/// <summary>
/// Checks if the object is null and throws an exception if it is.
/// </summary>
/// <typeparam name="T">The type of the object to check.</typeparam>
/// <param name="obj">The object to check.</param>
/// <param name="filePath">The filepath of the objects class.</param>
/// <param name="callerMemberName">The caller name of the object to check.</param>
/// <returns>The non-nullable object if the given object is not null.</returns>
/// <exception cref="SerializeFieldNotAssignedException">Throws the exception if the given object is null.</exception>
public static T ThrowIfNull<T>([NotNull] T? obj, [CallerFilePath] string filePath = "", [CallerMemberName] string callerMemberName = "")
where T : class
{
if (obj == null)
{
throw new SerializeFieldNotAssignedException(filePath, callerMemberName);
}
return obj;
}
}
}